<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SIP Phone(jssip version 3.10.0)</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 1100px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f5f5;
        }

        .container {
            background: white;
            padding: 30px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
        }

        .layout {
            display: flex;
            gap: 20px;
            flex-wrap: wrap;
        }

        .left-panel,
        .right-panel {
            flex: 1 1 320px;
            display: flex;
            flex-direction: column;
            gap: 20px;
        }

        .section {
            margin-bottom: 30px;
            padding: 20px;
            border: 1px solid #ddd;
            border-radius: 5px;
        }

        .section h3 {
            margin-top: 0;
            color: #333;
        }

        .form-group {
            margin-bottom: 15px;
        }

        label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }

        input[type="text"],
        input[type="password"],
        input[type="url"],
        input[type="tel"],
        input[type="number"],
        textarea,
        button {
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            width: 100%;
            box-sizing: border-box;
        }

        input[type="checkbox"] {
            width: auto;
            margin-right: 8px;
        }

        input::placeholder,
        textarea::placeholder {
            color: rgba(148, 163, 184, 0.7);
            opacity: 1;
        }

        label.checkbox-inline {
            display: inline-flex;
            align-items: center;
            gap: 8px;
        }

        button {
            background: #007bff;
            color: white;
            cursor: pointer;
            margin-top: 10px;
        }

        button:hover {
            background: #0056b3;
        }

        button:disabled {
            background: #ccc;
            cursor: not-allowed;
        }

        .status {
            padding: 10px;
            margin: 10px 0;
            border-radius: 4px;
        }

        .status.success {
            background: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }

        .status.error {
            background: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }

        .status.info {
            background: #d1ecf1;
            color: #0c5460;
            border: 1px solid #bee5eb;
        }

        .call-controls {
            display: none;
        }

        .call-controls.active {
            display: block;
        }

        .logs {
            height: 300px;
            overflow-y: auto;
            background: #f8f9fa;
            padding: 10px;
            border: 1px solid #ddd;
            font-family: monospace;
            font-size: 12px;
        }

        .incoming-call {
            display: none;
            background: #fff3cd;
            border: 1px solid #ffeaa7;
            padding: 20px;
            border-radius: 5px;
            margin: 10px 0;
        }

        .incoming-call.active {
            display: block;
        }

        .call-info {
            margin: 10px 0;
        }

        @media (max-width: 768px) {
            body {
                max-width: 100%;
            }

            .layout {
                flex-direction: column;
            }
        }
    </style>
</head>

<body>
    <div class="container">
        <h1>SIP Phone(jssip version 3.10.0)</h1>

        <div class="layout">
            <div class="left-panel">
                <div class="section">
                    <h3>Registration</h3>
                    <div class="form-group">
                        <label for="server">SIP Server (WebSocket URL):</label>
                        <input type="text" id="server" data-default-path="/ws" placeholder="ws://localhost:8080/ws">
                    </div>
                    <div class="form-group">
                        <label for="username">Username:</label>
                        <input type="text" id="username" value="bob" placeholder="bob">
                    </div>
                    <div class="form-group">
                        <label for="password">Password:</label>
                        <input type="password" id="password" value="123456" placeholder="password">
                    </div>
                    <div class="form-group">
                        <label for="displayName">Display Name:</label>
                        <input type="text" id="displayName" value="Bob" placeholder="Bob">
                    </div>
                    <div class="form-group">
                        <label class="checkbox-inline"><input type="checkbox" id="iceEnable" checked> Enable ICE
                            servers</label>
                        <textarea id="iceServers" rows="3"
                            style="width:100%;">[{"urls":"stun:stun.l.google.com:19302"}]</textarea>
                    </div>
                    <button id="registerBtn">Register</button>
                    <button id="unregisterBtn" disabled>Unregister</button>
                    <div id="registrationStatus"></div>
                </div>

                <div class="section">
                    <h3>Logs</h3>
                    <div class="logs" id="logs"></div>
                    <div style="margin-top:8px;">
                        <button onclick="clearLogs()">Clear Logs</button>
                    </div>
                </div>
            </div>

            <div class="right-panel">
                <div class="section">
                    <h3>Make Call</h3>
                    <div class="form-group">
                        <label for="callTarget">Call To:</label>
                        <input type="text" id="callTarget" value="alice" placeholder="alice">
                    </div>
                    <button id="callBtn" disabled>Call</button>

                    <div class="call-controls" id="callControls">
                        <div class="call-info" id="callInfo"></div>
                        <button id="hangupBtn">Hang Up</button>
                        <button id="muteBtn">Mute</button>
                        <button id="holdBtn">Hold</button>
                    </div>
                </div>

                <div class="incoming-call" id="incomingCall">
                    <h3>Incoming Call</h3>
                    <div id="callerInfo"></div>
                    <button id="answerBtn">Answer</button>
                    <button id="rejectBtn">Reject</button>
                </div>

                <div class="section">
                    <h3>ICE Stats</h3>
                    <div class="logs" id="iceStats" style="height:220px;">
                        <em>No ICE data yet.</em>
                    </div>
                    <audio id="remoteAudio" autoplay style="display:none;"></audio>
                    <audio id="localAudio" muted autoplay style="display:none;"></audio>
                </div>
            </div>
        </div>
    </div>

    <script src="//jssip.net/download/releases/jssip-3.10.0.min.js"></script>
    <script>
        'use strict';

        let ua = null;
        let currentSession = null;
        let isMuted = false;
        let isOnHold = false;
        let isRegistered = false;

        const serverInput = document.getElementById('server');
        const usernameInput = document.getElementById('username');
        const passwordInput = document.getElementById('password');
        const displayNameInput = document.getElementById('displayName');
        const registerBtn = document.getElementById('registerBtn');
        const unregisterBtn = document.getElementById('unregisterBtn');
        const registrationStatus = document.getElementById('registrationStatus');
        const callTargetInput = document.getElementById('callTarget');
        const callBtn = document.getElementById('callBtn');
        const callControls = document.getElementById('callControls');
        const callInfo = document.getElementById('callInfo');
        const hangupBtn = document.getElementById('hangupBtn');
        const muteBtn = document.getElementById('muteBtn');
        const holdBtn = document.getElementById('holdBtn');
        const incomingCallDiv = document.getElementById('incomingCall');
        const callerInfo = document.getElementById('callerInfo');
        const answerBtn = document.getElementById('answerBtn');
        const rejectBtn = document.getElementById('rejectBtn');
        const remoteAudio = document.getElementById('remoteAudio');
        const localAudio = document.getElementById('localAudio');
        const logs = document.getElementById('logs');
        const iceStatsPanel = document.getElementById('iceStats');
        const iceEnable = document.getElementById('iceEnable');
        const iceServersInput = document.getElementById('iceServers');
        let lastOutgoingCallTime = null;
        let lastOutgoingCallLabel = null;
        let iceServersManuallyEdited = false;

        if (iceServersInput) {
            iceServersInput.addEventListener('input', () => {
                iceServersManuallyEdited = true;
            });
        }

        const defaultServerUrl = (() => {
            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const host = window.location.host || 'localhost:8080';
            const pathRaw = (serverInput && serverInput.dataset && serverInput.dataset.defaultPath) || '/ws';
            const path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`;
            return `${protocol}//${host}${path}`;
        })();

        if (serverInput) {
            serverInput.placeholder = defaultServerUrl;
            if (!serverInput.value || serverInput.value === 'ws://localhost:8080/ws' || serverInput.value === 'wss://localhost:8080/ws') {
                serverInput.value = defaultServerUrl;
            }
        }

        function formatMs(ms) {
            if (ms == null) return '--';
            if (ms < 1000) return ms + ' ms';
            return (ms / 1000).toFixed(2) + ' s';
        }

        function resetIceStats(message = '<em>No ICE data yet.</em>') {
            if (iceStatsPanel) {
                iceStatsPanel.innerHTML = message;
            }
        }

        resetIceStats();

        async function loadDefaultIceServers() {
            if (!iceServersInput || iceServersManuallyEdited) {
                return;
            }
            try {
                const response = await fetch('/iceservers', { cache: 'no-store' });
                if (!response.ok) {
                    throw new Error(`status ${response.status}`);
                }
                const payload = await response.json();
                const servers = Array.isArray(payload)
                    ? payload
                    : (payload && Array.isArray(payload.iceServers))
                        ? payload.iceServers
                        : payload
                            ? [payload]
                            : null;
                if (!servers || !servers.length) {
                    throw new Error('empty ICE server list');
                }
                if (iceServersManuallyEdited) {
                    return;
                }
                iceServersInput.value = JSON.stringify(servers);
                if (iceEnable) {
                    iceEnable.checked = true;
                }
                log('Loaded ICE servers from /iceservers');
            } catch (error) {
                log(`Failed to load /iceservers: ${error.message}`);
            }
        }

        const ICE_INACTIVITY_TIMEOUT = 2500;

        function getIceTracker(session, label = 'session') {
            if (!session) {
                return null;
            }
            if (!session._iceTracker) {
                session._iceTracker = {
                    label,
                    candidates: [],
                    startedAt: null,
                    endedAt: null,
                    inactivityTimer: null,
                    gatherDuration: null,
                    status: 'waiting for candidates'
                };
            } else if (label && session._iceTracker.label !== label) {
                session._iceTracker.label = label;
            }

            session._iceCandidates = session._iceTracker.candidates;
            return session._iceTracker;
        }

        function updateIceStatsDisplay(session, statusText) {
            if (!iceStatsPanel) {
                return;
            }
            const tracker = getIceTracker(session, session && (session._iceLabel || `${session.direction || 'session'}`));
            if (!tracker) {
                return;
            }
            if (statusText) {
                tracker.status = statusText;
            }

            const parts = [
                `<div><strong>Call:</strong> ${tracker.label}</div>`,
                `<div><strong>Candidates:</strong> ${tracker.candidates.length}</div>`
            ];
            if (tracker.gatherDuration != null) {
                parts.push(`<div><strong>Gathering time:</strong> ${formatMs(tracker.gatherDuration)}</div>`);
            }
            if (tracker.status) {
                parts.push(`<div><strong>Status:</strong> ${tracker.status}</div>`);
            }
            if (tracker.candidates.length) {
                const rows = tracker.candidates.map((c, i) => {
                    const primaryAddress = c.address ? `${c.address}${c.port ? `:${c.port}` : ''}` : 'address unavailable';
                    const relayInfo = c.relatedAddress ? ` via ${c.relatedAddress}${c.relatedPort ? `:${c.relatedPort}` : ''}` : '';
                    return `${i + 1}. ${c.type || 'unknown'} ${primaryAddress}${relayInfo ? ` (${relayInfo.trim()})` : ''} (+${formatMs(c.ts)})`;
                });
                parts.push('<hr>');
                parts.push(rows.join('<br>'));
            }

            iceStatsPanel.innerHTML = parts.join('');
        }

        function parseIceCandidate(candidateString, candidateObject) {
            const out = { raw: candidateString };
            try {
                if (candidateObject) {
                    out.address = candidateObject.address || candidateObject.ip || null;
                    out.port = typeof candidateObject.port === 'number' ? candidateObject.port : parseInt(candidateObject.port, 10) || null;
                    out.type = candidateObject.type || null;
                    out.protocol = candidateObject.protocol || null;
                }
                if (typeof candidateString === 'string' && candidateString.length) {
                    const parts = candidateString.trim().split(/\s+/);
                    if (!out.address && parts.length >= 6) {
                        out.address = parts[4] || null;
                        const parsedPort = parseInt(parts[5], 10);
                        out.port = out.port || (Number.isNaN(parsedPort) ? null : parsedPort);
                    }
                    if (!out.type) {
                        const typeIndex = parts.indexOf('typ');
                        if (typeIndex > -1 && parts[typeIndex + 1]) {
                            out.type = parts[typeIndex + 1];
                        }
                    }
                    if (!out.protocol && parts.length >= 3) {
                        out.protocol = parts[2];
                    }
                    const raddrIndex = parts.indexOf('raddr');
                    const rportIndex = parts.indexOf('rport');
                    if (!out.relatedAddress && raddrIndex > -1 && parts[raddrIndex + 1]) {
                        out.relatedAddress = parts[raddrIndex + 1];
                    }
                    if (!out.relatedPort && rportIndex > -1 && parts[rportIndex + 1]) {
                        const parsedRport = parseInt(parts[rportIndex + 1], 10);
                        out.relatedPort = Number.isNaN(parsedRport) ? null : parsedRport;
                    }
                }
            } catch (error) {
                // ignore parsing errors, we fall back to raw candidate
            }
            return out;
        }

        function concludeIceGathering(session, reason) {
            const tracker = session && session._iceTracker;
            if (!tracker || tracker.endedAt) {
                return;
            }
            tracker.endedAt = Date.now();
            tracker.gatherDuration = tracker.startedAt ? (tracker.endedAt - tracker.startedAt) : null;
            const readableReason = reason ? reason.replace(/_/g, ' ') : 'completed';
            tracker.status = readableReason;
            session._iceGatherDuration = tracker.gatherDuration;
            log(`${tracker.label} - ICE gathering finished (${readableReason})${tracker.gatherDuration != null ? `, duration=${formatMs(tracker.gatherDuration)}` : ''}`);
            if (tracker.inactivityTimer) {
                clearTimeout(tracker.inactivityTimer);
                tracker.inactivityTimer = null;
            }
            updateIceStatsDisplay(session);
        }

        function scheduleIceInactivityTimeout(session) {
            const tracker = session && session._iceTracker;
            if (!tracker) {
                return;
            }
            if (tracker.inactivityTimer) {
                clearTimeout(tracker.inactivityTimer);
            }
            tracker.inactivityTimer = setTimeout(() => concludeIceGathering(session, 'inactivity timeout'), ICE_INACTIVITY_TIMEOUT);
        }

        function handleSessionIceCandidate(session, candidateObj) {
            if (!session || !candidateObj) {
                concludeIceGathering(session, 'no candidate');
                return;
            }

            const label = session._iceLabel || `${session.direction || 'session'}`;
            const tracker = getIceTracker(session, label);
            const candidateString = candidateObj.candidate || '';

            if (!candidateString) {
                concludeIceGathering(session, 'null candidate');
                return;
            }

            const now = Date.now();
            if (!tracker.startedAt) {
                tracker.startedAt = now;
                tracker.status = 'gathering';
                log(`${tracker.label} - ICE gathering started`);
            }

            const parsed = parseIceCandidate(candidateString, candidateObj);
            const entry = {
                ts: tracker.startedAt ? now - tracker.startedAt : 0,
                candidate: parsed.raw,
                type: parsed.type,
                address: parsed.address,
                port: parsed.port,
                relatedAddress: parsed.relatedAddress,
                relatedPort: parsed.relatedPort
            };

            if (entry.candidate && tracker.candidates.some((c) => c.candidate === entry.candidate)) {
                scheduleIceInactivityTimeout(session);
                updateIceStatsDisplay(session, 'gathering');
                return;
            }

            tracker.candidates.push(entry);
            const addrText = parsed.address ? `${parsed.address}${parsed.port ? `:${parsed.port}` : ''}` : 'address unavailable';
            log(`${tracker.label} - candidate #${tracker.candidates.length}: ${parsed.type || 'unknown'} ${addrText} (+${formatMs(entry.ts)})`);
            updateIceStatsDisplay(session, 'gathering');
            scheduleIceInactivityTimeout(session);
        }

        registerBtn.addEventListener('click', register);
        unregisterBtn.addEventListener('click', unregister);
        callBtn.addEventListener('click', makeCall);
        hangupBtn.addEventListener('click', hangup);
        muteBtn.addEventListener('click', toggleMute);
        holdBtn.addEventListener('click', toggleHold);
        answerBtn.addEventListener('click', answerCall);
        rejectBtn.addEventListener('click', rejectCall);

        function log(message) {
            const timestamp = new Date().toLocaleTimeString();
            logs.innerHTML += `[${timestamp}] ${message}<br>`;
            logs.scrollTop = logs.scrollHeight;
            console.log(message);
        }

        function clearLogs() {
            logs.innerHTML = '';
            resetIceStats();
        }

        function showStatus(message, type = 'info') {
            registrationStatus.className = `status ${type}`;
            registrationStatus.textContent = message;
        }

        function getDomain(serverUrl) {
            try {
                const url = new URL(serverUrl);
                return url.hostname;
            } catch (error) {
                log(`Invalid server URL: ${error.message}`);
                throw error;
            }
        }

        function register() {
            if (ua) {
                log('Reusing existing UA, stopping first.');
                ua.stop();
                resetRegistrationState();
            }

            const server = serverInput.value.trim();
            const username = usernameInput.value.trim();
            const password = passwordInput.value;
            const displayName = displayNameInput.value.trim();

            if (!server || !username) {
                showStatus('Please enter server and username', 'error');
                return;
            }

            let domain;
            try {
                domain = getDomain(server);
            } catch (error) {
                showStatus('Server URL is invalid', 'error');
                return;
            }

            registerBtn.disabled = true;
            unregisterBtn.disabled = true;
            showStatus('Connecting...', 'info');
            log('Attempting to register via JsSIP...');

            const socket = new JsSIP.WebSocketInterface(server);
            const configuration = {
                sockets: [socket],
                uri: `sip:${username}@${domain}`,
                password: password,
                display_name: displayName || username,
                register: true
            };

            // apply ICE servers if enabled
            try {
                if (iceEnable && iceEnable.checked) {
                    let parsed = null;
                    try {
                        parsed = JSON.parse(iceServersInput.value);
                    } catch (e) {
                        parsed = [{ urls: 'stun:stun.l.google.com:19302' }];
                    }
                    configuration.pcConfig = { iceServers: parsed };
                    log(`Using custom ICE servers: ${JSON.stringify(configuration.pcConfig)}`);
                } else {
                    log('ICE servers disabled (using default)');
                }
            } catch (e) {
                log(`Error applying ICE servers for UA: ${e.message}`);
            }

            ua = new JsSIP.UA(configuration);

            ua.on('connected', () => {
                log('WebSocket connected');
                showStatus('Connected', 'success');
            });

            ua.on('disconnected', () => {
                log('WebSocket disconnected');
                if (isRegistered) {
                    showStatus('Disconnected', 'error');
                }
                resetRegistrationState();
            });

            ua.on('registered', () => {
                isRegistered = true;
                log('Registered successfully');
                showStatus('Registered', 'success');
                registerBtn.disabled = true;
                unregisterBtn.disabled = false;
                callBtn.disabled = false;
            });

            ua.on('unregistered', () => {
                log('Unregistered from server');
                showStatus('Unregistered', 'info');
                resetRegistrationState();
            });

            ua.on('registrationFailed', (data) => {
                log(`Registration failed: ${data.cause}`);
                showStatus(`Registration failed: ${data.cause}`, 'error');
                ua.stop();
                resetRegistrationState();
            });

            ua.on('newRTCSession', (data) => {
                const session = data.session;

                if (currentSession) {
                    log('Already handling a session, rejecting the new one');
                    session.terminate({ status_code: 486, reason_phrase: 'Busy Here' });
                    return;
                }

                const isIncoming = data.originator === 'remote';
                incomingCallDiv.classList.remove('active');
                const remoteIdentity = session.remote_identity || {};
                const remoteUser = remoteIdentity.display_name || (remoteIdentity.uri && remoteIdentity.uri.user) || '';
                const outgoingName = lastOutgoingCallLabel || callTargetInput.value.trim() || remoteUser || 'unknown';
                const incomingName = remoteUser || 'unknown';
                session._iceLabel = isIncoming ? `incoming:${incomingName}` : `outgoing:${outgoingName}`;
                resetIceStats(`<div><strong>Call:</strong> ${session._iceLabel}</div><div><strong>Status:</strong> waiting for candidates</div>`);

                session._timing = {};
                if (data.originator === 'local') {
                    session._timing.callSent = lastOutgoingCallTime || Date.now();
                } else {
                    session._timing.incomingReceived = Date.now();
                }

                currentSession = session;
                bindSessionEvents(session, isIncoming);

                if (isIncoming) {
                    const caller = incomingName;
                    callerInfo.textContent = `Incoming call from: ${caller}`;
                    incomingCallDiv.classList.add('active');
                    log(`Incoming call from ${caller}`);
                } else {
                    callInfo.textContent = `Calling ${outgoingName}...`;
                    callControls.classList.add('active');
                }
            });

            ua.start();
        }

        function monitorIceGatheringJs(session, pc, label = 'session') {
            try {
                if (!pc || session._iceMonitorAttached) return;
                session._iceMonitorAttached = true;

                const tracker = getIceTracker(session, label);
                const statsLabel = tracker.label;
                updateIceStatsDisplay(session, tracker.status);

                pc.addEventListener('icegatheringstatechange', () => {
                    const state = pc.iceGatheringState;
                    log(`${statsLabel} - iceGatheringState -> ${state}`);
                    if (state === 'gathering' && !tracker.startedAt) {
                        tracker.startedAt = Date.now();
                        tracker.status = 'gathering';
                        updateIceStatsDisplay(session);
                    }
                    if (state === 'complete' && tracker.startedAt && !tracker.endedAt) {
                        concludeIceGathering(session, 'ice gathering complete');
                    }
                });

                pc.addEventListener('iceconnectionstatechange', () => {
                    const state = pc.iceConnectionState;
                    log(`${statsLabel} - iceConnectionState -> ${state}`);
                    if ((state === 'connected' || state === 'completed') && tracker.startedAt && !tracker.endedAt) {
                        concludeIceGathering(session, 'ice connection completed');
                    }
                });

                pc.addEventListener('connectionstatechange', () => {
                    const state = pc.connectionState;
                    log(`${statsLabel} - connectionState -> ${state}`);
                });

                updateIceStatsDisplay(session, tracker.status);
            } catch (e) {
                log(`monitorIceGatheringJs error: ${e.message}`);
            }
        }

        function unregister() {
            if (!ua) {
                return;
            }

            log('Unregistering from server...');
            if (currentSession) {
                hangup();
            }

            ua.unregister();
            ua.stop();
            resetRegistrationState();
            showStatus('Unregistered', 'info');
        }

        function makeCall() {
            if (!ua || !isRegistered) {
                log('UA is not registered');
                return;
            }

            const target = callTargetInput.value.trim();
            if (!target) {
                showStatus('Please enter a call target', 'error');
                return;
            }

            let domain;
            try {
                domain = getDomain(serverInput.value);
            } catch (error) {
                showStatus('Server URL is invalid', 'error');
                return;
            }

            const destination = target.startsWith('sip:') ? target : `sip:${target}@${domain}`;
            log(`Calling ${destination}`);

            const options = {
                mediaConstraints: { audio: true, video: false },
                rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false }
            };
            // record approximate time when we initiate the outgoing call request
            lastOutgoingCallTime = Date.now();
            lastOutgoingCallLabel = target;
            ua.call(destination, options);
            log('Invite sent successfully');
            callBtn.disabled = true;
        }

        function answerCall() {
            if (!currentSession) {
                return;
            }

            log('Answering incoming call');
            const options = {
                mediaConstraints: { audio: true, video: false },
                rtcAnswerConstraints: { mandatory: { OfferToReceiveAudio: true, OfferToReceiveVideo: false } }
            };

            try {
                // record answer time to compute handshake
                currentSession._timing = currentSession._timing || {};
                currentSession._timing.answerTime = Date.now();
                currentSession.answer(options);
                incomingCallDiv.classList.remove('active');
                callControls.classList.add('active');
            } catch (error) {
                log(`Failed to answer call: ${error.message}`);
                endCall();
            }
        }

        function rejectCall() {
            if (!currentSession) {
                return;
            }

            log('Rejecting incoming call');
            currentSession.terminate({ status_code: 486, reason_phrase: 'Busy Here' });
            endCall();
        }

        function hangup() {
            if (!currentSession) {
                return;
            }

            log('Hanging up call');
            try {
                currentSession.terminate();
            } catch (error) {
                log(`Error terminating session: ${error.message}`);
            }
            endCall();
        }

        function toggleMute() {
            if (!currentSession) {
                log('No active session to mute');
                return;
            }

            try {
                if (isMuted) {
                    currentSession.unmute({ audio: true });
                    muteBtn.textContent = 'Mute';
                    log('Audio unmuted');
                } else {
                    currentSession.mute({ audio: true });
                    muteBtn.textContent = 'Unmute';
                    log('Audio muted');
                }
                isMuted = !isMuted;
            } catch (error) {
                log(`Error toggling mute: ${error.message}`);
            }
        }

        function toggleHold() {
            if (!currentSession) {
                log('No active session to hold');
                return;
            }

            try {
                if (typeof currentSession.isEstablished === 'function' && !currentSession.isEstablished()) {
                    log('Call is not yet established, cannot toggle hold');
                    return;
                }
                if (isOnHold) {
                    currentSession.unhold();
                    holdBtn.textContent = 'Hold';
                    log('Call resumed');
                } else {
                    currentSession.hold();
                    holdBtn.textContent = 'Unhold';
                    log('Call placed on hold');
                }
                isOnHold = !isOnHold;
            } catch (error) {
                log(`Error toggling hold: ${error.message}`);
            }
        }

        function bindSessionEvents(session, isIncoming) {
            callBtn.disabled = true;

            session.on('progress', () => {
                log('Call state: Establishing');
                if (!isIncoming) {
                    callInfo.textContent = 'Ringing...';
                }
            });

            session.on('accepted', () => {
                log('Call state: Accepted');
            });

            session.on('confirmed', () => {
                log('Call state: Established');
                callInfo.textContent = `Connected to ${session.remote_identity.display_name || session.remote_identity.uri.user}`;
            });

            // log handshake timings when call is confirmed/established
            session.on('confirmed', () => {
                try {
                    const t = session._timing || {};
                    const remoteUser = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
                    if (t.callSent) {
                        const dur = Date.now() - t.callSent;
                        log(`Outgoing handshake time to ${remoteUser}: ${formatMs(dur)}`);
                    } else {
                        const base = t.answerTime || t.incomingReceived;
                        if (base) {
                            const dur = Date.now() - base;
                            log(`Incoming handshake time from ${remoteUser}: ${formatMs(dur)}`);
                        }
                    }
                } catch (e) { log(`handshake timing error: ${e.message}`); }
            });

            session.on('ended', (data) => {
                const directionLabel = isIncoming ? 'Incoming' : 'Outgoing';
                const cause = data && data.cause ? ` (${data.cause})` : '';
                log(`${directionLabel} call state: Terminated${cause}`);
                endCall();
            });

            session.on('failed', (data) => {
                const directionLabel = isIncoming ? 'Incoming' : 'Outgoing';
                const cause = data && data.cause ? ` (${data.cause})` : '';
                log(`${directionLabel} call state: Failed${cause}`);
                showStatus(`Call failed: ${data.cause}`, 'error');
                endCall();
            });

            session.on('hold', () => {
                isOnHold = true;
                holdBtn.textContent = 'Unhold';
                log('Call is on hold');
            });

            session.on('unhold', () => {
                isOnHold = false;
                holdBtn.textContent = 'Hold';
                log('Call is resumed from hold');
            });

            session.on('muted', () => {
                isMuted = true;
                muteBtn.textContent = 'Unmute';
                log('Call audio muted');
            });

            session.on('unmuted', () => {
                isMuted = false;
                muteBtn.textContent = 'Mute';
                log('Call audio unmuted');
            });

            session.on('peerconnection', (data) => {
                attachMedia(data.peerconnection);
                try {
                    const fallbackRemote = (session.remote_identity && session.remote_identity.uri && session.remote_identity.uri.user) || 'peer';
                    const label = session._iceLabel || `${session.direction || 'session'}:${fallbackRemote}`;
                    monitorIceGatheringJs(session, data.peerconnection, label);
                } catch (e) { }
            });
            session.on('icecandidate', (event) => {
                try {
                    handleSessionIceCandidate(session, event ? event.candidate : null);
                } catch (error) {
                    log(`handleSessionIceCandidate error: ${error.message}`);
                }
            });
        }

        function attachMedia(peerconnection) {
            const remoteStream = new MediaStream();
            peerconnection.addEventListener('track', (event) => {
                if (event.track.kind !== 'audio') {
                    return;
                }
                if (event.streams && event.streams.length > 0) {
                    remoteAudio.srcObject = event.streams[0];
                } else {
                    remoteStream.addTrack(event.track);
                    remoteAudio.srcObject = remoteStream;
                }
                log('Remote audio stream attached');
            });

            const senders = peerconnection.getSenders();
            if (senders && senders.length > 0) {
                const localStream = new MediaStream();
                senders.forEach((sender) => {
                    if (sender.track && sender.track.kind === 'audio') {
                        localStream.addTrack(sender.track);
                    }
                });

                if (localStream.getTracks().length > 0) {
                    localAudio.srcObject = localStream;
                    log('Local audio stream attached');
                }
            }
        }

        function endCall() {
            callControls.classList.remove('active');
            incomingCallDiv.classList.remove('active');
            callInfo.textContent = '';

            if (remoteAudio.srcObject) {
                remoteAudio.srcObject.getTracks().forEach((track) => track.stop());
                remoteAudio.srcObject = null;
            }

            if (localAudio.srcObject) {
                localAudio.srcObject.getTracks().forEach((track) => track.stop());
                localAudio.srcObject = null;
            }

            currentSession = null;
            isMuted = false;
            isOnHold = false;
            muteBtn.textContent = 'Mute';
            holdBtn.textContent = 'Hold';
            callBtn.disabled = !isRegistered;
            resetIceStats();
        }

        function resetRegistrationState() {
            isRegistered = false;
            registerBtn.disabled = false;
            unregisterBtn.disabled = true;
            callBtn.disabled = true;
            endCall();
            ua = null;
        }

        window.addEventListener('beforeunload', () => {
            if (ua) {
                ua.stop();
            }
        });

        loadDefaultIceServers();
        log('SIP Phone initialized');
    </script>
</body>

</html>